package an.chopsticks.integration.url;

import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

import an.chopsticks.service.Action;
import an.chopsticks.service.Attribute;
import an.chopsticks.service.AttributeType;
import an.chopsticks.service.AuthenticationFailedException;
import an.chopsticks.service.AuthenticationService;
import an.chopsticks.service.AuthorizationFailedException;
import an.chopsticks.service.AuthorizationResult;
import an.chopsticks.service.AuthorizationService;
import an.chopsticks.service.Context;
import an.chopsticks.service.Credential;
import an.chopsticks.service.Decision;
import an.chopsticks.service.DefaultContextImpl;
import an.chopsticks.service.Resource;
import an.chopsticks.service.ServiceManager;
import an.chopsticks.service.Session;
import an.chopsticks.service.SessionManager;
import an.chopsticks.service.SessionSizeExceedCapacityException;
import an.chopsticks.service.Subject;
import an.control.OperationFailedException;
import an.log.LogFactory;
import an.log.Logger;

public class HttpRequestFilter implements Filter {
    public static final String KEY_SESSION_MGR = "an.SessionManager";
    public static final String LOGIN_PAGE = "login-page";
    public static final String LOGIN_ACTION = "login-action";
    public static final String LOGOUT_URL = "logout-url";
    public static final String ATN_FAIL = "failed-authentication-page";
    public static final String ATZ_FAIL = "failed-authorization-page";
    public static final String ERROR_PAGE = "error-page";
    public static final String CONFIG_FILE = "configFile";
    public static final String ENABLE = "enableProtection";
    private static final Set<String> PARAMS = new HashSet<String>();
    static {
        PARAMS.add(LOGIN_PAGE);
        PARAMS.add(LOGOUT_URL);
        PARAMS.add(ATN_FAIL);
        PARAMS.add(ATZ_FAIL);
        PARAMS.add(ERROR_PAGE);
        PARAMS.add(CONFIG_FILE);
    }

    static final String PARAM_MESSAGE = "an.Message";
    static final String PARAM_LOGOUT = "logout";
    static final String KEY_ORIGINAL_URL = "an.OriginalURL";
    static final String USER = "j_username";
    static final String PASS = "j_password";

    private String loginPage;
    private String loginAction;
    private String logoutUrl;
    private String atnFailPage;
    private String atzFailPage;
    private String errorPage;

    private boolean enable = true;
    private ServiceManager mgr;
    private AuthenticationService atnSvc;
    private AuthorizationService atzSvc;
    private SessionManager sessionMgr;
    private Logger logger;

    public void destroy() {
        if (enable) {
            try {
                mgr.shutdownForce();
            } catch (OperationFailedException e) {
                String msg = "Failed to shutdown the service manager.";
                logger.error(msg, e);
            }
        }
    }

    public void doFilter(ServletRequest req, ServletResponse res, FilterChain next)
    throws IOException, ServletException {
        // If security is not enabled, we just let it pass.
        if (!enable) {
            next.doFilter(req, res);
            return;
        }

        if (req instanceof HttpServletRequest) {
            HttpServletRequest request = (HttpServletRequest)req;

            HttpSession httpSession = request.getSession(true);
            // we set the session manager to http session then we can get and use it in JSPs.
            if (httpSession.getAttribute(KEY_SESSION_MGR) == null) {
                httpSession.setAttribute(KEY_SESSION_MGR, sessionMgr);
            }

            String uri = request.getRequestURI();
            String relativeUri = uri.substring(request.getContextPath().length());
            // we by pass those information pages.
            if (relativeUri.equals(loginPage) || relativeUri.equals(errorPage) || relativeUri.equals(atnFailPage) ||
                relativeUri.equals(atzFailPage)) {
                next.doFilter(req, res);
                return;
            }
            // do login
            if (relativeUri.equals(loginAction)) {
                // If uri is login action, we are trying to do authentication.
                doAuthentication(request, (HttpServletResponse)res);
                return;
            }
            // do logout
            if (relativeUri.equals(logoutUrl)) {
                logout(request);
                logger.debug("Logged out, forward to home page.");
                ((HttpServletResponse)res).sendRedirect(request.getContextPath());
                return;
            }

            // Any other pages.
            String queryString = request.getQueryString();
            String url = uri + (queryString == null ? "" : ("?" + queryString));
            logger.debug("Coming request : " + url);

            // Construct authorization request. Set current subject as anonymous
            Subject[] subjects = new Subject[] {Subject.ANONYMOUS};
            Resource[] resources = processResource(uri);
            Action action = new Action(request.getMethod());
            Context ctx = createContextFromRequest(request);

            try {
                AuthorizationResult result = null;
                Session session = sessionMgr.getSession(httpSession.getId());
                Subject subject = session.getSubject();
                // If current session is not authenticated, we are trying to use anonymous, or we forward it to login
                // page.
                if (subject == null) {
                    logger.debug("Current session is not logged in, we are authorizing as Anonymous.");
                    result = doAuthorization(subjects, resources, action, ctx);
                    if (!(result.getDecision() == Decision.Permit)) {
                        logger.debug("Anonymous is not allowed, we forward it to login page.");
                        // We save the accessed url to session, then if logged in, we will redirect to the original url.
                        httpSession.setAttribute(KEY_ORIGINAL_URL, url);
                        request.getRequestDispatcher(loginPage).forward(req, res);
                        return;
                    }
                }
                else {
                    logger.debug("Got subject from session, doing authorization.");
                    subjects = new Subject[] {subject};
                    result = doAuthorization(subjects, resources, action, ctx);
                }

                Decision decision = result.getDecision();
                String msg = result.getMessage();
                Context responseCtx = result.getResponseContext();
                logger.debug("Decsion=" + decision + ";Subject=" + subject.getSubjectName() + ";Resource=" + uri +
                        ";Action=" + action.getActionName() + 
                        (responseCtx == null ? "" : ";ResponseContext=[" + responseCtx + "]"));

                // Set the returned message to request
                if (msg != null) {
                    logger.debug("\n" + msg);
                    request.setAttribute(PARAM_MESSAGE, msg);
                }
                // Set the response context's attributes to request
                if (responseCtx != null) {
                    Collection<Attribute> attrs = responseCtx.getAllAttributes();
                    for (Attribute each : attrs) {
                        request.setAttribute(
                                each.getName(), each.isArray() ? each.getArrayValueAsString() : each.getValue());
                    }
                }

                if (decision == Decision.Permit) {
                    logger.debug("Request '" + url + "' was permit to be access.");
                    next.doFilter(req, res);
                }
                else {
                    String atzFailMsg = "Request '" + url + "' was not permit to be access.";
                    logger.debug(atzFailMsg);
                    request.setAttribute(PARAM_MESSAGE, atzFailMsg);
                    if (atzFailPage == null) {
                        ((HttpServletResponse)res).sendError(HttpServletResponse.SC_FORBIDDEN);
                    }
                    else {
                        request.getRequestDispatcher(atzFailPage).forward(req, res);
                    }
                }
            } catch (SessionSizeExceedCapacityException se) {
                String msg = "Session pool full: " + se.getMessage();
                logger.error(msg, se);
                request.setAttribute(PARAM_MESSAGE, msg);
                if (errorPage == null) {
                    ((HttpServletResponse)res).sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
                }
                else {
                    request.getRequestDispatcher(errorPage).forward(req, res);
                }
            } catch (AuthorizationFailedException ae) {
                String msg = "Error occurs while authorizing: " + ae.getMessage();
                logger.error(msg, ae);
                request.setAttribute(PARAM_MESSAGE, msg);
                if (errorPage == null) {
                    ((HttpServletResponse)res).sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
                }
                else {
                    request.getRequestDispatcher(errorPage).forward(req, res);
                }
            }
        }
        else {
            String msg = "We got a non-HttpServletRequest.";
            logger.warn(msg);
            req.setAttribute(PARAM_MESSAGE, msg);
            if (errorPage == null) {
                ((HttpServletResponse)res).sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
            }
            else {
                req.getRequestDispatcher(errorPage).forward(req, res);
            }
        }
    }

    private AuthorizationResult doAuthorization(Subject[] subjs, Resource[] reses, Action action, Context ctx)
    throws AuthorizationFailedException {
        AuthorizationResult[] result = null;
        for (Resource res : reses) {
            result = atzSvc.authorize(subjs, new Resource[] {res}, action, ctx);
            if (result[0].getDecision() == Decision.Permit) {
                return result[0];
            }
        }
        return result == null ? null : result[0];
    }

    /**
     * XXX Before PDP support multiple resource profile, we have to sent multiple requests to PDP.
     * @param uri
     * @return
     */
    private Resource[] processResource(String uri) {
        String splitter = "/";
        List<Resource> result = new ArrayList<Resource>();
        // Trim the ending "/"
        while (uri.endsWith(splitter)) {
            uri = uri.substring(0, uri.length() - 1);
        }
        if (uri.equals("")) {
            return new Resource[] {new Resource(splitter)};
        }

        result.add(new Resource(uri));
        int pos = uri.lastIndexOf(splitter);
        while (pos > 0) {
            uri = uri.substring(0, pos);
            result.add(new Resource(uri));
            pos = uri.lastIndexOf(splitter);
        }
        // Add the root finally.
        result.add(new Resource(splitter));
        return result.toArray(new Resource[0]);
    }

    private void doAuthentication(HttpServletRequest req, HttpServletResponse resp)
    throws ServletException, IOException {
        String user = req.getParameter(USER);
        String test = req.getParameter(PASS);
        if (user == null || test == null) {
            String msg = "Username or password is null.";
            logger.warn(msg);
            req.setAttribute(PARAM_MESSAGE, msg);
            req.getRequestDispatcher(loginPage).forward(req, resp);
            return;
        }

        NamePasswordCredential cred = new NamePasswordCredential();
        cred.setUserName(user);
        cred.setPassword(test.getBytes());

        try {
            Subject subj = atnSvc.authenticate(cred, createContextFromRequest(req));
            logger.debug("Authentication succeeded. Subject=[" +
                    subj.getSubjectName() + "];ResponseContext=[" + subj + "]");

            HttpSession httpSession = req.getSession(true);
            Session session = sessionMgr.getSession(httpSession.getId());
            // Put subject to current session.
            session.setSubject(subj);

            // Set the response context's attributes to request
            Collection<Attribute> attrs = subj.getAllAttributes();
            for (Attribute each : attrs) {
                req.setAttribute(
                        each.getName(), each.isArray() ? each.getArrayValueAsString() : each.getValue());
            }

            String url = (String)httpSession.getAttribute(KEY_ORIGINAL_URL);
            if (url == null) {
                logger.debug("Forward to home page.");
                // we redirect to the root context.
                req.getRequestDispatcher("/").forward(req, resp);
                return;
            }
            logger.debug("Forward to original url : '" + url + "'");
            // Re-direct to the original url before login.
            resp.sendRedirect(url);
        } catch (AuthenticationFailedException e) {
            String msg = "Authenticaton failed: " + e.getMessage();
            logger.debug(msg, e);
            req.setAttribute(PARAM_MESSAGE, msg);
            req.getRequestDispatcher(atnFailPage).forward(req, resp);
        } catch (SessionSizeExceedCapacityException e) {
            String msg = "Session pool full: " + e.getMessage();
            logger.debug(msg, e);
            req.setAttribute(PARAM_MESSAGE, msg);
            req.getRequestDispatcher(errorPage).forward(req, resp);
        }
    }

    private void logout(HttpServletRequest req) {
        HttpSession hsession = req.getSession(true);
        try {
            Session session = sessionMgr.getSession(hsession.getId());
            session.invalidate();
            hsession.invalidate();
        } catch (SessionSizeExceedCapacityException e) {}
    }

    public void init(FilterConfig config) throws ServletException {
        String anconfigFile = loadParameters(config);
        if (enable) {
            try {
                mgr = ServiceManager.getInstance(anconfigFile);
                logger = LogFactory.getLogger();
                atzSvc = (AuthorizationService)mgr.getService(AuthorizationService.class);
                atnSvc = (AuthenticationService)mgr.getService(AuthenticationService.class);
                sessionMgr = SessionManager.getInstance(mgr);
                logger.info("The protection has been enabled.");
            } catch (Exception e) {
                enable = false;
                String msg = "Error occurs while initialize services, the protection has been disabled";
                if (logger != null) {
                    logger.error(msg, e);
                }
                throw new ServletException(msg, e);
            }
        }
    }

    @SuppressWarnings("unchecked")
    private Context createContextFromRequest(HttpServletRequest req) {
        Enumeration e = req.getParameterNames();
        if (e != null) {
            Context ctx = new DefaultContextImpl();
            while (e.hasMoreElements()) {
                String name = (String)e.nextElement();
                if (!name.equals(USER) && !name.equals(PASS)) {
                    try {
                        // Test if it is an valid URI, otherwise, we don't add it to context.
                        new URI(name);
                        ctx.setAttribute(new Attribute(name, AttributeType.String, req.getParameter(name)));
                    } catch (URISyntaxException e1) {
                        continue;
                    }
                }
            }
            return ctx;
        }
        return null;
    }

    @SuppressWarnings("unchecked")
    private String loadParameters(FilterConfig config) throws ServletException {
        String strEnable = config.getInitParameter(ENABLE);
        if (strEnable != null && strEnable.equalsIgnoreCase("false")) {
            enable = false;
        }
        else {
            enable = true;
        }

        String anconfigFile = config.getInitParameter(CONFIG_FILE);
        if (anconfigFile == null) {
            throw new ServletException("Can't get initialize parameters.");
        }
        // We provide default values for those pages.
        loginPage = config.getInitParameter(LOGIN_PAGE);
        if (loginPage == null) {
            loginPage = "/security/loginform.jsp";
        }
        loginAction = config.getInitParameter(LOGIN_ACTION);
        if (loginAction == null) {
            loginAction = "/security/j_security_check";
        }
        logoutUrl = config.getInitParameter(LOGOUT_URL);
        if (logoutUrl == null) {
            logoutUrl = "/security/logout";
        }
        atnFailPage = config.getInitParameter(ATN_FAIL);
        if (atnFailPage == null) {
            atnFailPage = loginPage;
        }
        atzFailPage = config.getInitParameter(ATZ_FAIL);
        errorPage = config.getInitParameter(ERROR_PAGE);

        // Populate all other parameters as JVM options.
        Enumeration<String> e = config.getInitParameterNames();
        while (e.hasMoreElements()) {
            String name = e.nextElement();
            if (!PARAMS.contains(name)) {
                System.setProperty(name, config.getInitParameter(name));
            }
        }
        return anconfigFile;
    }
}

class NamePasswordCredential implements Credential {
    private Map<String, Object> cred = new HashMap<String, Object>();
    public Object getCredentialObject() {
        return cred;
    }

    public String getType() {
        return Credential.TYPE_ATN_NAMEPASS;
    }

    public void setUserName(String user) {
        cred.put(Credential.KEY_USERNAME, user);
    }

    public void setPassword(byte[] password) {
        cred.put(Credential.KEY_PASSWORD, password);
    }
}